Skip to content

fix: give every parsed object a node spanning its source - #2870

Open
knutwannheden wants to merge 7 commits into
JSQLParser:masterfrom
knutwannheden:fix/binary-operator-ast-span
Open

knutwannheden wants to merge 7 commits into
JSQLParser:masterfrom
knutwannheden:fix/binary-operator-ast-span

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Most objects JSqlParser returns cannot be mapped back to their source text. Across the 1,513 statements in the test suite's SQL, 6,575 objects that implement ASTNodeAccess return null from getASTNode(). Among them are every column's table qualifier, every WHEN ... THEN, nested AND/OR and inner arithmetic. Some existing ranges miss part of their text:

String sql = "SELECT t.a FROM t START WITH x IS NULL CONNECT BY PRIOR id = parent_id AND d > CURRENT_DATE - 7";
// master: no node for the Table t, x IS NULL, the AND, or CURRENT_DATE - 7,
// and the node for PRIOR id = parent_id spans only "id = parent_id"
over the test suite's SQL master this PR
ASTNodeAccess objects without a node 6,575 0
ranges outside the object that holds them 126 0
expression ranges whose text parses to something else 50 0

On master, statements, OrderByElement and ColDataType do not implement ASTNodeAccess, so they are not counted.

What changes

  • RangeNode. Objects that no grammar rule gives a node are linked to a RangeNode, a node outside the parse tree that records the object's first and last token. These are objects built in the Pratt operator loops, in productions without a # node, or owned by the model, such as a PARTITION BY clause. getASTRoot() returns the same tree, and no tree node changes its value or range.
  • Ranges that missed text the object prints now cover it. This applies to a statement-level WITH, an Oracle PRIOR, a column's (+), LIMIT ... BY, sequence options, IF ... ELSE and merged adjacent literals. The object moves to a RangeNode over its full text, which answers its tree node's parent, children and id, so getParent() and tree walks from the object's node are unchanged. A string literal no longer takes the node of the primary expression around it, as in 'a'::text.
  • Comments get positions. The lexer runs no token action for a comment, so SimpleCharStream now records where each match began. CommonTokenAction then gives the comments before each token their positions, and an Oracle hint gets a range.
  • Statements and two more classes can hold a node. OrderByElement, ColDataType, and the 118 statement classes that implemented only Statement now extend ASTNodeAccessImpl. SingleStatement(), blocks and unsupported statements link them. The statement sweep is its own commit.

How it is checked

ASTNodeCoverageTest parses the SQL files under src/test/resources with error recovery, the Oracle tests in the Oracle dialect, and walks every object through its getters. Each ASTNodeAccess object must have a node that:

  • spans at least one character;
  • lies inside the range of the object that holds it, and of the object getParent() returns;
  • does not partly overlap any other range;
  • holds no reference to the parser;
  • for an expression or a statement, covers text that parses back to the same object, ignoring case and whitespace.

The same checks run over statements the corpus lacks, such as data-modifying CTEs and Oracle blocks. Another test pins ranges the checks cannot judge, such as WHEN, data types, PARTITION BY, (+), LIMIT BY and hints. Each check fails when one of the fixes is undone.

Compatibility and cost

  • getParent() returns null when it runs out of tree, instead of throwing. A RangeNode for an object with no tree node has no parent.
  • The protected prattArithRest and prattExpressionRest take the token their operand chain starts at.
  • Over the same corpus, the parse results retain 14.1 MB against 13.6 MB on master (+3.7%), and a parse allocates 1.3% more. Parse time, compared in six interleaved pairs, is up 0.7% by the median difference, with this PR slower in 5 of the 6.
  • A 20,000-row INSERT ... VALUES allocates 3.2% more and retains the same, and its parse time shows no difference: 3 pairs faster, 2 slower, 1 tied.

Not covered

  • A part inside one token, such as the user and host of an unquoted app@localhost, has no token of its own.
  • Classes that do not implement ASTNodeAccess, such as Alias, ColumnDefinition, WithItem, GroupByElement and UpdateSet, still have no range.
  • Grammar the test corpus does not reach was fixed by reading it, not by a failing check.

prattArithRest builds operator expressions in plain Java, outside any
production, so JJTree never opened a node for them and `CURRENT_DATE - 7`
or `a || b` had no source range. Each expression the loop builds is now
linked to a node from the token its operand chain starts at to the last
token consumed. The node stays out of the tree, so the operands' nodes
keep their place.
@knutwannheden
knutwannheden marked this pull request as draft October 29, 2026 01:33
@knutwannheden

Copy link
Copy Markdown
Contributor Author

Converting this to a draft while I extend it to the other expressions that still come out of the parser without an AST node. Going through the test corpus turned up nested AND/OR, IS NULL, BETWEEN, CASE's WHEN clauses and a few more. I'll update the description once they're in.

The statement classes that implemented Statement alone now extend
ASTNodeAccessImpl, as Select and Import already did, so a parsed
statement can hold the node that records its source range. Nothing
links them yet.
Objects no grammar rule opens a node for, such as a column's table
qualifier, a WHEN clause, nested AND/OR, IS NULL, BETWEEN, parameter
lists, data types, ORDER BY elements and statements, are linked to a
RangeNode: a node outside the parse tree that records the object's first
and last token. getASTRoot() returns the same tree, and no tree node
gains or changes its value.

Ranges that missed text their object prints now cover it: a statement's
WITH, Oracle PRIOR and (+), LIMIT ... BY, sequence options, IF ... ELSE
and merged adjacent literals. A string literal no longer takes the node
of the primary expression around it. Comments get positions, so an
Oracle hint has a range too. getParent() returns null instead of
throwing once it runs out of tree.

ASTNodeCoverageTest parses the test resources' SQL and requires every
ASTNodeAccess object to have a node of at least one character, inside
its holder's range, never partly overlapping another, holding no
parser, and, for expressions and statements, covering text that parses
back to the same object.
@knutwannheden knutwannheden changed the title fix: give arithmetic expressions an AST node fix: give every parsed object a node spanning its source Oct 29, 2026
Statements that never pass through SingleStatement get their node where
they are built: data-modifying CTEs, statements inside an Oracle block,
CREATE SCHEMA elements, CREATE TABLE ... AS EXECUTE and the GRANT or
REVOKE of ALTER DEFAULT PRIVILEGES. An IF statement's range takes in the
semicolon it prints.

A RangeNode that widens a tree node answers its children, id and dump
too, so a WITH statement's node still shows the tree beneath it.

Comments are positioned in one backwards pass over the recorded match
begins, and comments before the end of input are positioned once
parsing ends, since the end-of-file token gets no token action.

Values in a primary expression, string literals, expression lists and
row patterns get a RangeNode only once something wraps them, so a
production's own tree node is the only node most of them get. On a
20,000-row INSERT ... VALUES this cuts the added allocation from 12.9%
to 3.2%.

The coverage test parses the Oracle tests in the Oracle dialect, checks
that getParent() encloses each range, and runs its checks over the
statements above, which the test corpus does not contain.
@knutwannheden
knutwannheden marked this pull request as ready for review October 29, 2026 03:31
@manticore-projects

manticore-projects commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Please fix Codacy issues.
Also, please I want to see the Performance/Benchmarks before: ./gradlew jmh. Thank you already.

`visit` and `checkRange` exceeded PMD's NPath threshold of 200, so the
container walk and the parent check move into methods of their own.
`isHeld` compares with `==` on purpose: a getter that answers a new
object on every call computes its child rather than holding it.
@knutwannheden

Copy link
Copy Markdown
Contributor Author

Thanks! The Codacy findings are fixed in e8de6e9. All four were in the new ASTNodeCoverageTest: visit and checkRange are split to get under the NPath limit, and the == in isHeld is a deliberate identity check, now suppressed the same way as elsewhere in the code base.

I ran ./gradlew jmh with the project's settings, alternating master (c84ad0e) and this branch twice on the same machine. Every run also measures the released 5.4 jar, which serves as a control:

run master this branch 5.4 (master run / branch run)
1 37.41 ± 1.54 ms/op 38.17 ± 1.73 ms/op 48.42 / 48.45 ms/op
2 37.79 ± 2.05 ms/op 38.58 ± 1.46 ms/op 48.39 / 47.82 ms/op

This branch parses performance.sql about 2% slower. That is within the error bars, but consistent across both pairs while the 5.4 control holds steady, so I take it to be real. The branch records a range node for every object the grammar opens no tree node for. I'm looking into deriving the range of objects like binary expressions from their operands on first access instead, which should recover most of it.

@manticore-projects

Copy link
Copy Markdown
Contributor

Good Job, give me a few minutes for proof reading everything.

`Condition()` created a deque for the tokens of a leading `NOT` on every
call, though few conditions have one. Each empty `ArrayDeque` allocates
its 16-slot array up front, so parsing `performance.sql` allocated
585 KB more per parse. The deque is now created at the first `NOT`.
@knutwannheden

Copy link
Copy Markdown
Contributor Author

Most of that came from my own change: a deque Condition() created on every call for the tokens of a leading NOT, though few conditions have one, and an empty ArrayDeque allocates its 16-slot array up front. 0c8073f creates it only at the first NOT. Bytes allocated per parse of performance.sql (median of 20 parses):

master this branch before this branch with 0c8073f
6,783,600 7,622,376 (+12.4%) 7,037,624 (+3.7%)

What remains is the range nodes themselves. ./gradlew jmh again, alternating as before:

run master this branch 5.4 (master run / branch run)
1 38.64 ± 1.99 ms/op 38.24 ± 1.85 ms/op 48.27 / 48.46 ms/op
2 37.77 ± 2.00 ms/op 38.68 ± 1.32 ms/op 47.81 / 47.71 ms/op

The branch now averages 0.7% slower, less than master moved between its own two runs (2.3%). Deriving ranges from operands would remove only a small part of what is left, so I'd leave that for a follow-up. The same profile shows a few allocations on master worth trimming, such as the exclamationMarks deque next to the one I fixed, which I'll send as a separate PR.

Only the generated parser creates a `RangeNode`, and callers see it as
the `Node` that `getASTNode()` returns. Keeping the class package-private
leaves its design free to change before a release.
@knutwannheden

Copy link
Copy Markdown
Contributor Author

Looking past this PR: for the next major release, I think it would be cleaner to stop exposing the parser's internals through the AST.

  • getASTNode() hands out JJTree's Node and JavaCC's Token, generated classes with mutable public fields.
  • Their tree follows the grammar's # nodes, not the model. That is why this PR needs RangeNode: an object the grammar opens no node for has to be given one to report its range.
  • Any reachable node keeps the rest of the token stream alive through Token.next.

What callers need is the source range. ASTNodeAccess could offer begin and end offsets in place of getASTNode(), stored as two ints the parser sets, and RangeNode would go with it. Deprecating getASTNode() ahead of that would give callers time to move.

@manticore-projects

Copy link
Copy Markdown
Contributor

Sorry for asking a stupid question: why not implement for Statement interface (only) and provide DEFAULT methods?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants